<!--
  - 接触角-卷积迁移项目
  - 详见简介
 -->
 
<!-- 视图层 -->
<template>

<!-- TFjs初始化组件 -->
<tfjsInit />

<!-- 功能简介 -->
<uni-card title="功能简介">
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "操作方法：
    ① 读取预训练模型。
    ② 上传蒸馏水或其它对照品的接触角照片，并输入接触角数据（用于自适应迁移学习校正）。
    ③ 上传待测样品照片。
    注 迁移模型立足于预训练模型，为保证反馈性能而设置了停止训练指标。可多次预测以确保收敛。后期将进一步开放批量传图、批量获得接触角（及其它相关物性数据），并一键下载为excel表格等功能。"
  />
  <!-- 文字 -->
  <view><text>
    　　立足接触角测量的业务需求，使用优化了的计算机视觉方法处理溶液水滴照片，以不损失照片中水滴接触角实际精度信息为前提，将图片数据标准化为可训练的三维张量数据集。然后采用卷积神经网络处理溶液水滴照片，旨在对图片中的水滴接触角及其它物性参数做出预测。\n
    　　面向高校物理化学实验教学使用时，因为实验条件可能存在差异：如实验操作时的温度条件差异，不同高校、不同专业学生选用的标准试剂差异，因学生实验操作习惯不同而造成的图像采集误差，等等。\n
    　　因实验条件存在上述诸多差异，故实际教学环境下的数据往往包含复杂的系统误差，需进行数据校正。本业务所采用的方法即对预训练模型进行自适应的迁移学习方法，具体在于：\n
    　　建立卷积神经网络模型，具体模型参数可在加载模型后详见图表版。\n
    　　通过已有训练集（特征：照片 - 标签：接触角）得到预训练模型。\n
    　　固化已有模型，添加全连接层作为头部层。并以蒸馏水或其它样品空白/阴性对照的图片为校正用数据，训练得到迁移模型。\n
    　　在用户端，加载读取预训练模型后，迁移模型的各类设置、训练均以算法实现自适应完成。
  </text></view>
  <!-- 二级标题：参考文献 -->
  <uni-section
    title="参考库链接："
    type="circle"
    padding="0 1em"
  >
    <!-- 链接：库 -->
    <uni-link
      text="https://js.tensorflow.org/"
      href="https://js.tensorflow.org/api/latest/"
      class="center" color="blue"
    />
  </uni-section>
</uni-card>

<!-- 重力感应组件 -->
<gravity-init />

<!-- 加载预训练模型 -->
<uni-card title="加载预训练模型">
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "加载预训练模型，并展示模型的拓扑结构概览。"
  />
  <!-- 加载按钮容器 -->
  <view class="center">
    <!-- 加载模型按钮 -->
    <button
      :disabled="isGetModelRef"
      @click="fetchPretrainingModel()"
      size="mini" type="primary"
    >
      {{ isGetModelRef ? "加载完成" : "加载预训练模型" }}
    </button>
    <!-- 模型概览 -->
    <button
      v-if="isGetModelRef"
      @click="showVisor()"
      size="mini" type="primary"
    >
      模型概览
    </button>
  </view>
  <!-- 读取本地模型容器：折叠面板 -->
  <uni-collapse v-if="!isGetModelRef">
    <!-- 下拉框容器 -->
    <uni-collapse-item
      title="(可选)从本地读取模型"
    >
      <!-- 读取文件容器 -->
      <view class="center">
        <!-- 拓扑结构文件 -->
        <uni-file-picker
          @select="(event) => readModelFile(event.tempFiles[0])"
          class="center"
          file-extname="json" fileMediatype="all" limit="1" return-type="object"
          :auto-upload="false" :del-icon="false" :disable-preview="true"
        >
          <!-- 文件上传按钮 -->
          <button size="mini" type="primary">
            读取拓扑文件
          </button>
        </uni-file-picker>
        <!-- 权重参数文件 -->
        <uni-file-picker
          @select="(event) => readModelFile(event.tempFiles[0])"
          class="center"
          file-extname="bin" fileMediatype="all" limit="1" return-type="object"
          :auto-upload="false" :del-icon="false" :disable-preview="true"
        >
          <!-- 文件上传按钮 -->
          <button size="mini" type="primary">
            读取权重文件
          </button>
        </uni-file-picker>
      </view>
    </uni-collapse-item>
  </uni-collapse>
</uni-card>

<!-- 标准图片 -->
<uni-card title="读取样品空白(可选)">
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "读取样品空白/阴性对照图片，并输入接触角测量值，以校正模型。
    也可不进行校正操作，即直接使用预训练模型。
    【注意】！目前模型未训练完成，如果校正值过大，可能会训练自适应很久！"
  />
  <!-- 二级标题：样品空白/阴性对照图片 -->
  <uni-section
    title="样品空白/阴性对照图片(可选)"
    type="circle"
    padding="0 1em"
  >
    <!-- 空白/阴性对照图片上传容器 -->
    <uni-file-picker
      @select="(event) => readPicFile(event.tempFiles[0], 'reference')"
      fileMediatype="image" limit="1" return-type="object"
      :auto-upload="false" :del-icon="false" :disable-preview="true"
    />
  </uni-section>
  <!-- 二级标题：样品空白/阴性对照接触角测量值 -->
  <uni-section
    title="样品空白/阴性对照接触角测量值(可选)"
    type="circle"
    padding="0 1em"
  >
    <!-- 内容行 -->
    <uni-easyinput
      :disabled="!isGetReferencePicTensorRef"
      v-model="referenceValueRef"
      type="number"
      placeholder="请输入接触角测量值"
      trim="all" :clearable="false"
    />
  </uni-section>
</uni-card>

<!-- 待测图片 -->
<uni-card title="读取待测样品">
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "读取待测样品水滴图片，得到接触角数据。"
  />
  <!-- 二级标题：样品水滴图片 -->
  <uni-section
    title="样品水滴图片"
    type="circle"
    padding="0 1em"
  >
    <!-- 待测样品图片上传容器 -->
    <uni-file-picker
      @select="(event) => readPicFile(event.tempFiles[0], 'sample')"
      fileMediatype="image" limit="1" return-type="object"
      :auto-upload="false" :del-icon="false" :disable-preview="true"
    />
  </uni-section>
</uni-card>

<!-- 获取接触角数据 -->
<uni-card title="获取接触角数据">
  <!-- 通告栏 -->
  <uni-notice-bar text=
    "通过模型进行预测，获取接触角数据。"
  />
  <!-- 训练按钮容器及按钮 -->
  <view class="center"><button
    :disabled="!tfBackendRef || !isGetModelRef || !isGetSamplePicTensorRef"
    @click="predictMain()"
    size="mini" type="primary"
  >
    获取接触角数据
  </button></view>
</uni-card>

<!-- 页脚容器 -->
<view class="center"><text>
  \n
  参赛作品-参赛作品-接触角卷积迁移模型
</text></view>

</template>

<!-- 逻辑层 -->
<script setup>
// 导入vue的框架方法
import { shallowRef } from "vue"
// 导入tfjs和tfjs-visor库
import * as tf from "@tensorflow/tfjs"
// import * as tfvis from "@tensorflow/tfjs-vis"
// 导入TF初始化组件
import tfjsInit from "../index/tfjs-init.vue"
// 导入重力感应初始化组件
import gravityInit from "../index/gravity-init-comp.vue"
// 导入公共方法库的方法
import {
  // tfjs计算后端挂载完毕的Ref对象
  tfBackendRef,
  // 显示图表板的方法
  showVisor,
  // 显示模型摘要的方法
  showModelSummary,
} from "../../scripts/app_common.js"

/**
 * @ref对象
 */
// 是否获取预训练模型
const isGetModelRef = shallowRef(false)
// 是否获取校准用张量
const isGetReferencePicTensorRef = shallowRef(false)
// 校准图片的校准值
const referenceValueRef = shallowRef(0)
// 是否获取待测张量
const isGetSamplePicTensorRef = shallowRef(false)

/**
 * @其他对象
 */
// 预训练模型
const modelObj = {
  // 预训练模型
  pretraining: null,
  // 迁移模型
  transfer: null,
  // 输入文件的高modelWeightsFile
  inputHeight: null,
  // 输入文件的宽
  inputWidth: null,
  // 模型JSON文件
  modelJsonFile: null,
  // 模型权重文件
  modelWeightsFile: null,
}
// 图片对象
const imgObj = {
  // 对照图片
  reference: null,
  // 待测图片
  sample: null
}
// 图片张量
const tensorObj = {
  // 对照/空白图片张量
  reference: null,
  // 待测图片张量
  sample: null
}
// 模型文件目录名
const modelDirName = "contact-angle-model_241028a"
// 模型拓扑和权重文件的路径
// development和production模式下，路径不同
const modelJsonUrl = process.env.NODE_ENV === "development" ?
  `../static/${ modelDirName }/model.json` : `./static/${ modelDirName }/model.json`
const modelWeightsUrl = process.env.NODE_ENV === "development" ?
  `../static/${ modelDirName }/model.weights.bin` : `./static/${ modelDirName }/model.weights.bin`


/**
 * @readModelFile 读取模型(文件)
 * 包含读取JSON文件和权重文件
 * @function
 * @param {{
 *   file: File
 *   extname: String
 * }} fileMapObj 封装的文件对象
 */
async function readModelFile(fileMapObj) { try {
  // 显示加载圈
  uni.showLoading({ title: "正在读取文件..." })
  // 根据文件后缀名，分配拓扑文件和权重文件
  if (fileMapObj.extname === "json") {
    modelObj.modelJsonFile = fileMapObj.file
  } else if (fileMapObj.extname === "bin") {
    modelObj.modelWeightsFile = fileMapObj.file
  }
  // 如果两个文件都传了，就开始解析
  if (modelObj.modelJsonFile && modelObj.modelWeightsFile) {
    const model = await tf.loadLayersModel(
      tf.io.browserFiles([modelObj.modelJsonFile, modelObj.modelWeightsFile])
    )
    // 可视化模型概要
    showModelSummary(model, "已有模型概述")
    // showModelLayers(model, "模型参数")
    // 把模型的输入参数传给全局变量
    modelObj.pretraining = model
    modelObj.inputHeight = model.inputs[0].shape[1]
    modelObj.inputWidth = model.inputs[0].shape[2]
    // 尝试将图片转为张量
    await imgFixToTensor()
    // 删掉文件对象节省内存
    modelObj.modelJsonFile = null
    modelObj.modelWeightsFile = null
    // 已获取模型标签
    isGetModelRef.value = true
  }
  // 隐藏加载圈
  uni.hideLoading()
} catch (error) {
  console.error("readModelFile()报错: ", error)
  uni.hideLoading()
  uni.showModal({
    showCancel: false,
    title: "读取模型(文件)报错",
    content: error,
  })
}}

/**
 * @readPicFile 读取图片文件
 * @function async
 * @param { File } file 读取到的文件
 * @param { String } tensorName 用于接收图片张量的ID名
 * 读取图片并转为729 x 729的图片张量，赋值给全局变量tensor[tensorName]
 */
async function readPicFile(file, tensorName) { try {
  // 显示加载圈
  uni.showLoading({ title: "正在读取图片数据" })
  // 构造<img>元素
  const imgElement = new Image()
  // 在网页内隐藏<img>元素
  imgElement.style.display = "none"
  // 将图片路径挂载在<img>元素上
  imgElement.src = file.url
  // 等待图片加载完成
  await imgElement.decode()
  // 最后，分类
  if (tensorName === "reference") {
    // 传出<img>对象
    imgObj.reference = imgElement
    // 更改ref状态
    isGetReferencePicTensorRef.value = true
  } else if (tensorName === "sample") {
    // 传出<img>对象
    imgObj.sample = imgElement
    // 更改ref状态
    isGetSamplePicTensorRef.value = true
  }
  // 尝试转换图片张量
  await imgFixToTensor()
  // 隐藏加载圈
  uni.hideLoading()
} catch (error) {
  console.error("readPicFile()报错: ", error)
  uni.hideLoading()
  uni.showModal({
    showCancel: false,
    title: "读取图片文件报错",
    content: error,
  })
}}

/**
 * @imgFixToTensor 图片转尺寸再转张量
 * @function async
 * @note 会读写imgObj[imgName]和tensorObj[imgName]
 */
async function imgFixToTensor() { try {
  // 如果没有模型尺寸信息，则直接返回
  if (!modelObj.inputWidth || !modelObj.inputHeight) {
    return
  }
  // 有模型尺寸，那就看情况操作:
  // 如果如果有对照图片，则读取对照图片
  if (imgObj.reference) {
    tensorObj.reference = await imgToTensor(imgObj.reference)
    // 把图片销毁
    imgObj.reference = null
  }
  // 如果如果有对照图片，则读取对照图片
  if (imgObj.sample) {
    // 图片转张量
    tensorObj.sample = await imgToTensor(imgObj.sample)
    // 把图片销毁
    imgObj.sample = null
  }
} catch (error) {
  console.error("imgFixToTensor()报错: ", error)
}}

/**
 * @imgToTensor 图片转张量
 * @function async 是imgFixToTensor()方法的嵌套子方法
 * @param { HTMLImageElement } imgElement 图片<img>元素
 * @returns { Promise<tf.Tensor> } 图片张量
 * @note 会读写modelObj的inputWidth和inputHeight
 */
async function imgToTensor(imgElement) { try {
  // 修改对照图片<img>元素的宽高，以让长边符合要求
  if (
    (imgElement.naturalWidth / modelObj.inputWidth) 
    >= (imgElement.naturalHeight / modelObj.inputHeight)
  ) {
    imgElement.width = modelObj.inputWidth
    imgElement.height = imgElement.naturalHeight 
      / imgElement.naturalWidth * modelObj.inputHeight
  } else {
    imgElement.height = modelObj.inputHeight
    imgElement.width = imgElement.naturalWidth 
      / imgElement.naturalHeight * modelObj.inputWidth
  }
  // 等待挂载解码完成
  await imgElement.decode()
  // 从<img>元素构造图片张量
  const imgTensor = tf.tidy(() => {
    // 获取原始图片的张量
    const originTensor = tf.browser.fromPixels(imgElement)
    // 计算需要填充的量
    const padHeight = modelObj.inputHeight - imgElement.height
    const padWidth = modelObj.inputWidth - imgElement.width
    // 填充
    const paddedTensor = tf.pad(originTensor, [
      // 第一维度，即高度。除不尽的时候，上面少填充一个像素
      [Math.floor(padHeight / 2), Math.ceil(padHeight / 2)],
      // 第二维度，即宽度。除不尽的时候，左边少填充一个像素
      [Math.floor(padWidth / 2), Math.ceil(padWidth / 2)],
      // 第三维度，即通道数。不需要填充
      [0, 0]
    ])
    // 打一个batch包
    const batchTensor = tf.expandDims(paddedTensor, 0).div(255.0)
    // 返回处理好的张量
    return batchTensor
  })
  // 返回处理好的张量
  return imgTensor
} catch (error) {
  console.error("imgToTensor()报错: ", error)
}}

/**
 * @fetchPretrainingModel 加载预训练模型
 * @function async
 * 读取图片并转为1215 x 1215的图片张量，赋值给全局变量tensor[tensorName]
 */
async function fetchPretrainingModel() { try {
  // 显示加载圈
  uni.showLoading({ title: "正在加载预训练模型" })
  // 加载模型，得到Response对象
  const modelResponse = await fetch(modelJsonUrl)
  const weightResponse = await fetch(modelWeightsUrl)
  // 搞Response失败，则报错
  if (!modelResponse.ok || !weightResponse.ok) {
    console.log("模型加载失败")
    // 隐藏加载圈
    uni.hideLoading()
    // 报错
    uni.showModal({
      showCancel: false,
      title: "加载失败",
      content: "预训练模型加载失败",
    })
  // 否则，即成功了
  } else {
    // 模型拓扑：Response对象 => Blob对象 => File对象
    const modelBolb = await modelResponse.blob()
    // 模型文件：把读取的模型文件命名为model.json，以防匹配出错
    const modelFile = new File(
      [modelBolb],
      "model.json",
      { type: modelBolb.type }
    )
    // 模型权重：Response对象 => Blob对象 => File对象
    const weightBolb = await weightResponse.blob()
    // 模型文件：把读取的模型文件命名为model.weights.bin，以防匹配出错
    const weightFile = new File(
      [weightBolb],
      "model.weights.bin",
      { type: weightBolb.type }
    )
    // 读取预训练模型
    modelObj.pretraining = await tf.loadLayersModel(
      tf.io.browserFiles([modelFile, weightFile])
    )
    // 模型参数
    modelObj.inputHeight = modelObj.pretraining.inputs[0].shape[1]
    modelObj.inputWidth = modelObj.pretraining.inputs[0].shape[2]
    // 尝试将图片转为张量
    await imgFixToTensor()
    // 固化模型所有层
    for (const layer of modelObj.pretraining.layers) {
      layer.trainable = false
    }
    // 输出模型结构
    // modelObj.pretraining.summary()
    showModelSummary(modelObj.pretraining, "预训练模型结构")
    // 建立迁移模型
    transferModel()
    // 更改ref状态
    isGetModelRef.value = true
    // 隐藏加载圈
    uni.hideLoading()
  }
} catch (error) {
  console.error("fetchPretrainingModel()报错: ", error)
  uni.hideLoading()
  uni.showModal({
    showCancel: false,
    title: "加载预训练模型报错",
    content: error,
  })
}}

/**
 * @transferModel 建立迁移模型
 * @function
 * 读取modelObj的预训练模型，建立迁移模型
 */
function transferModel() { try {
  // 建立最后一层迁移层。启用偏差，不设定激活函数
  const transferDense = tf.layers.dense({
    name: "transfer-dense",
    // 输出空间的维度
    units: 1,
    // 启用偏差
    useBias: true,
    // 不设激活函数
    activation: undefined,
    // 初始化器：权重为1
    kernelInitializer: "ones",
    // 初始化器：偏差为0
    biasInitializer: "zeros",
  // 直接接上预训练模型的输出层
  }).apply(modelObj.pretraining.output)
  // 建立迁移模型
  modelObj.transfer = tf.model({
    name: "transfer-model",
    // 输入层：原输入
    inputs: modelObj.pretraining.inputs,
    // 输出层：迁移层
    outputs: transferDense
  })
  // 模型建立好了，可以从里面提取出迁移层，固化迁移层权重
  // 找到最后一层（头部层）标签
  const headLayerIndex = modelObj.transfer.layers.length - 1
  // 获取权重和偏置
  const headLayerWeights = modelObj.transfer.layers[headLayerIndex].getWeights()
  // 设置权重不可训练，[0]是权重，[1]是偏置
  headLayerWeights[0].trainable = false
  // 编译模型
  modelObj.transfer.compile({
    // 优化器：Adam
    // 学习率0.1，以后再调小一点
    optimizer: tf.train.adam(0.1),
    // 损失函数：均方误差
    loss: [tf.losses.meanSquaredError],
    // 评估指标：平均绝对误差
    metrics: [tf.metrics.meanAbsoluteError],
  })
  // 打印模型结构
  modelObj.transfer.summary()
  // showModelSummary(modelObj.transfer, "迁移模型结构")
} catch (error) {
  console.error("fetchPretrainingModel()报错: ", error)
  uni.hideLoading()
  uni.showModal({
    showCancel: false,
    title: "加载预训练模型报错",
    content: error,
  })
}}

/**
 * @transferModelTrain 迁移模型训练
 * @function
 */
async function transferModelTrain() {
  // 先条件判断：没加载模型；或没加载参考图片；或标签是0
  if (
    !modelObj.transfer
    || !tensorObj.reference
    || Number(referenceValueRef.value) === 0
  ) {
    // 返回false
    return false
  }
  // 否则，把标签搞一下
  const referenceTensor = tf.tidy(() => {
    // 拿到标签数据，以(-1, 1)正则化，建立tensor
    const originRefTensor = 
      tf.tensor1d([Number(referenceValueRef.value)]).sub(90).div(90.0)
    // 返回建立的tensor
    return originRefTensor
  })
  // 接损失值得变量
  let previousLoss = null
  // 然后训练迁移模型
  await modelObj.transfer.fit(
    // 训练集
    tensorObj.reference,
    // 标签集
    referenceTensor,
    {
      // 最大迭代次数，即训练轮数
      epochs: 100,
      // 每轮结束的回调
      callbacks: { onEpochEnd: (epoch, logs) => {
        // 接当前损失值
        const currentLoss = logs.loss
        // 如果之前有损失值
        if (previousLoss !== null) {
          // 计算损失值变化率
          const lossChangeRate = (currentLoss - previousLoss) / previousLoss
          // 如果变化率小于0.1，就是不怎么变了；或者变化率大于0，就是损失值增加了
          if ((lossChangeRate < 0.1) || (lossChangeRate > 0)) {
            // 那就停止训练
            console.log(`自适应训练${ epoch + 1 }次结束。`)
            modelObj.transfer.stopTraining = true
          // 否则
          } else {
            // 更新损失值
            previousLoss = currentLoss
          }
        }
      }}
    }
  )
  // 返回true
  return true
}

/**
 * @predictMain 预测方法
 * @function
 * 模型预测
 */
async function predictMain() { try {
  // 显示加载圈
  uni.showLoading({ title: `模型自适应预测中...`})
  // 训练迁移模型（如果可选的话），返回是否用迁移模型
  const isUseTransferModel = await transferModelTrain()
  // 模型预测结果
  let predictResult = null
  // 如果可用迁移模型预测
  if (isUseTransferModel) {
    // 用迁移模型预测
    predictResult = tf.tidy(() => {
      // 预测，并接收预测结果tensor
      const predictResultTensor = modelObj.transfer.predict(tensorObj.sample)
      // tensor转为数组，乘以系数
      const predictResultArr = predictResultTensor.arraySync()
      const predictResult = predictResultArr[0][0] * 90.0 + 90.0
      // 输出
      return predictResult
    })
  } else {
    // 用预训练模型预测
    predictResult = tf.tidy(() => {
      // 预测，并接收预测结果tensor
      const predictResultTensor = modelObj.pretraining.predict(tensorObj.sample)
      // tensor转为数组，乘以系数
      const predictResult = predictResultTensor.arraySync()[0][0] * 90.0 + 90.0
      // 输出
      return predictResult
    })
  }
  // 隐藏加载圈
  uni.hideLoading()
  // 显示预测结果
  uni.showModal({
    showCancel: false,
    title: "预测结果",
    content: isUseTransferModel
        ?
      `迁移模型预测结果为 ${ predictResult.toFixed(2) } 度`
        :
      `预训练模型预测结果为 ${ predictResult.toFixed(2) } 度`,
  })
} catch (error) {
  console.error("predictMain()报错: ", error)
  uni.hideLoading()
  uni.showModal({
    showCancel: false,
    title: "预测报错",
    content: error,
  })
}}

</script>
